Java进阶 - 线程探索

前言

深入浅出,对Java多线程的探索 - 笔者的一段学习笔记,如果错漏,恳请指教。

前提概念

原子性

  • 概念:一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
  • 加锁可以保证复合语句的原子性,sychronized可以保证多条语句在synchronized块中语意上是原子的。由Java内存模型来直接保证的原子性变量操作包括read、load、use、assign、store和write六个,大致可以认为基础数据类型的访问和读写是具备原子性的。如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock与unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐匿地使用这两个操作,这两个字节码指令反映到Java代码中就是同步块—synchronized关键字,因此在synchronized块之间的操作也具备原子性。

可见性

  • 概念:当一个线程修改了线程共享变量的值,其它线程能够立即得知这个修改。
  • Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方法来实现可见性的,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是volatile的特殊规则保证了新值能立即同步到主内存,以及每使用前立即从内存刷新。因为我们可以说volatile保证了线程操作时变量的可见性,而普通变量则不能保证这一点。
  • 除了volatile之外,Java还有两个关键字能实现可见性,它们是synchronized。同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store和write操作)”这条规则获得的,而final关键字的可见性是指:被final修饰的字段是构造器一旦初始化完成,并且构造器没有把“this”引用传递出去,那么在其它线程中就能看见final字段的值。

顺序性

  • 概念:程序执行的顺序按照代码的先后顺序执行。(JVM真正执行代码时候,为了提高程序运行效率,可能对输入的代码进行优化,不能保证程序中各个语句执行先后顺序与代码一致,但能保证程序最终运行结果与代码顺序执行结果一致)Java内存模型中的程序天然有序性可以总结为一句话:如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前半句是指“线程内表现为串行语义”,后半句是指“指令重排序”现象和“工作内存主主内存同步延迟”现象。
    Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一时刻只允许一条线程对其进行lock操作”这条规则来获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。

并行、并发以及线程安全

  • 并行:真正的同时执行(同一时刻可以多个进程执行)
  • 并发:通过CPU调度算法,不是真正的同时执行(同一时刻最多只有一个进程在执行)
  • 线程安全:并发情况下,某代码经过多线程调用,而线程调用顺序不影响操作结果(火车卖票问题,多处卖票,保证一票一卖)
  • 同步:Synchronized 关键字,通过人为控制和调度,保证共享资源的多线程访问的线程安全;①同步不仅可以阻止一个线程看到对象处于不一致的状态中,②它还可以保证进入同步方法或者同步代码块的每个线程,都看到同一个锁保护的之前所有的修改结果
  • Volatile 关键字:轻量级的synchronized,对比synchronized,volatile变量所需编码更少,并且运行开销也会较少。Volatile 读操作开销很小,如果读操作远远大于写操作,单独使用Volatile可以提供优于锁的性能优势。(后面会与大家一同对该关键字进行探讨)

java内存模型

保证原子性

1
2
3
4
x = 10;//1
y = x;//2
x++;//3
x = x + 1;//4

只有语句1是原子性操作 - 语句1直接将10赋值给x(将数值10写入工作内存);语句2包含两个原子性操作(读x值,将x写入内存),合起来就不是原子性操作了(因为可能中间会被打断,造成一个有效,一个无效的情况,语句3,4也是同理);语句3包含3个操作(读x值,加1,写入内存)

总结:只有简单的读取、赋值才是原子操作(必须是将具体数值赋值给某个变量,变量之间的赋值不是原子操作);Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围的原子性,需要通过synchronized和lock来实现(保证任意时刻只有一个线程执行对应代码块)

保证可见性

volatile关键字可保证可见性:当一个共享变量被volatile修饰时,它会保证修改的值会立即更新到主存,当有其他线程需要读取,它会去内存中读取新值。(当然,synchronized、Lock 也可以保证)

有序性

synchronized、lock通过保证线程同步,自然保证了有序性。另外,通过volatile关键字也可保证一定的“有序性”(具体原理稍候再描述)

注意:Java内存模型中,如果操作遵循“先行发生原理”(happens-before),则不需要通过任何手段就可以保证有序性。

先行发生原则(happens-before):

  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
  • 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作
  • volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
  • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
  • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

前四条主要规则解释:

  • 第一条:在单线程中,程序执行看起来是有序的(因为在保证执行结果一致的前提下,虚拟机可能会对程序代码进行重排序 - 也就是仅对不存在数据依赖性的指令进行重排序)
  • 第二条: 如果锁处于锁定状态,那么必须对锁进行释放,后面才能继续进行lock操作
  • 第三条: 如果某线程写入一个变量,另外一个线程去读取,那么,写入操作必须要在读取操作之前
  • 第四题: 显而易见的传递性

探讨Volatile关键字

文章及书本推荐:

  • 正确使用 Volatile 变量
  • Volatile关键字解析
  • 《深入理解 Java 虚拟机》- 周志明(著)

    volatile 关键字两层含义

    1. 保证了不同线程对该变量的可见性
    注意:volatile 变量在各个线程的工作内存中,可以存在不一致的情况,但由于每次使用都要先刷新,执行引擎看不到不一致的情况,因此可认为不存在一致性问题,但java中运算操作并不是原子性操作,导致volatile变量的运算在并发状态下一样是不安全的,下面尝试用代码说明。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    public class LearningVolatile {
    public static volatile int race = 0;
    public static void increase() {
    race++;
    }
    private static final int THREADS_COUNT = 20;
    public static void main(String[] args) {
    Thread[] threads = new Thread[THREADS_COUNT];
    for (int i = 0; i < THREADS_COUNT; i++) {
    threads[i] = new Thread(new Runnable() {
    @Override
    public void run() {
    for (int i = 0; i < 10000; i++) {
    increase();
    }
    }
    });
    threads[i].start();
    }
    // 等待所有累加线程结束
    while (Thread.activeCount() > 1) {
    Thread.yield();
    }
    System.out.println(race);
    }
    }
    //每次运行结果不同,总为一个小于200000的数字
    //笔者数次输出结果为:73000+

代码剖析:
如果正确并发,理论结果应为200000,显然,这段程序并没有正确并发,使用javap 命令反编译得到字节码。可以看到,race++ 一行代码包含:取值(getstatic-volatile保证,此时取race值的正确性)、将常量压入栈(iconst_1)、(将两个栈顶int值相加并压入栈顶)iadd,写入(putstatic),就如上面解析的一样,这一系列操作合起来,就不再符合原子性了,当取值之后,其他线程可能已经把race加大了,本线程的race值则变成过期数据,最后写入错误的race值到主内存中。
另:其实采用字节码来分析,也是欠缺严谨的,即使编译出来只有一条字节码,也并不代表该指令就是一个原子操作(因为一条字节码执行时候,解析器还是要运行多行代码才可以实现它的语义,使用 -XX:+PrintAssembly参数输出反汇编来分析会更加严谨),但这里字节码已经能够说明问题,可不必再深入细究。

1
2
3
4
5
6
7
8
//反编译字节码(increase方法)
public static void increase();
Code:
0: getstatic #2 // Field race:I
3: iconst_1
4: iadd
5: putstatic #2 // Field race:I
8: return

volatile 主要适用场景:

  1. 运算结果不依赖变量当前值,能够确保只有单一线程修改变量的值
  2. 变量不需要与其他的状态变量共同参与不变约束

2. 保证了不同线程对该变量的可见性
关键需要理解:为何指令重排序会干扰程序的并发执行?
例子:初始化完成的标识,如果指令重排,可能会导致 initialized = true提前执行,使B线程运行出现问题

1
2
3
4
5
6
7
8
9
10
A线程中
volatile boolean initialized = false;
XXX x = new XXX();//模拟初始化
...
initialized = true;//说明初始化完成
B线程中
while(!initialized){
sleep();
}
doSomethingWithAConfig();//接下来,就可以利用A中初始好的配置信息进行操作啦

Java monitor(同步机制)

  • 推荐文章:探索Java同步机制
  • 概念:Java Monitor 从两个方面来支持线程之间的同步,即:互斥执行与协作。Java 使用对象锁 ( 使用 synchronized 获得对象锁 ) 保证工作在共享的数据集上的线程互斥执行 , 使用 notify/notifyAll/wait 方法来协同不同线程之间的工作。这些方法在 Object 类上被定义,会被所有的 Java 对象自动继承。
    实质上,Java 的 Object 类本身就是监视者对象,Java 语言对于这样一个典型并发设计模式做了内建的支持。

    线程探索

    线程状态

    • ThreadState:NEW、RUNNABLE、BLOCKED、WAITTING、TIMED_WAITING、TERMINATED
      • NEW:线程还没 start 时候
      • RUNNABLE:线程在 JVM 中执行时候
      • BLOCKED:线程等待监控锁时候(阻塞)
      • WAITTING:线程不确定时长等待另外一个线程进行一个实际操作时候
      • TIMED_WAITING:线程正在等待另外一个线程进行操作,直到特定的时间结束时候
      • TERMINATED:线程已退出
    • 状态转换图(join - 阻塞当前执行进程,直到调用该方法的线程执行完毕才释放、yield - 让出当前执行位置,让其他线程执行,与join方法恰好相反)

      注意:join、sleep方法wait、synchronized方法 不同。
      • join、sleep恢复时,线程从阻塞状态回到RUNNABLE状态,等待JVM调度 — 阻塞 -> 可运行
      • wait方法,使线程处于等待池(wait blocked pool),直到notify/notifyAll方法唤醒线程,将被唤醒的线程放到锁定池(lock blocked pool),释放同步锁使线程回到RUNNABLE状态— 等待池 -> 锁定池 -> 可运行
      • synchronized 同步锁,则使线程进入锁定池(lock blocked pool),待释放同步锁再回到RUNNABLE状态— 锁定池 -> 可运行
      • yield方法,使RUNNING状态的线程进入RUNNABLE状态
      • RUNNABLE状态下,线程调度顺序不一定

线程类相关

主要的线程相关类:Thread类、Runnable接口、Callable接口、Future类

Thread

Thread类实现了Runnable接口,常用的Thread类相关方法:

1
2
3
4
5
6
7
start();//启动线程
yield();//让出CPU,让其他就绪状态(RUNNABLE)的线程运行
sleep();//停滞,使线程进入阻塞状态,但不能改变对象的机锁(仍持有对象锁,其他线程不可访问该对象),注意与wait()方法区分
wait();//等待,释放对象锁(其他线程可访问),因此必须要放到 synchronized 代码块中,否则会抛出“java.lang.IllegalMonitorStateException”异常,使用notify或者noyifyAll方法来唤醒当前等待池中的线程
join();//阻塞当前执行的线程,直到调用该方法的线程执行完毕才释放
interrupte();//检查当前线程是否被打断(返回boolean类型)
interrupted();//将中断状态标识置为true

注意:Thread的异常处理,需要在run方法中,使用try/catch来处理,另外有方法setUncaughtExceptionHandler来处理 uncheck exception

Runnable接口

推荐通过实现Runnable接口,而非继承Thread,从而避免单继承的局限性。

Callable接口、Future

Callable接口与Runnable接口相似,但是Runnable不会返回结果,并且无法抛出返回结果的异常,而Callable功能更强大一些,被线程执行后,可以返回值,这个返回值可以被Future拿到,基本使用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
//Future的两个方法
future.isDone() //return true,false 无阻塞
future.get() // return 返回值,阻塞直到该线程运行结束
// 方法1:FutureTask实现了两个接口,Runnable和Future,所以它既可以作为Runnable被线程执行,又可以作为Future得到Callable的返回值
public static void main(String[] args) {
Callable<Integer> callable = new Callable<Integer>() {
public Integer call() throws Exception {
// 返回码
return new Random().nextInt(100);
}
};
FutureTask<Integer> future = new FutureTask<Integer>(callable);
new Thread(future).start();
try {
Thread.sleep(5000);// 模拟业务逻辑
System.out.println(future.get());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
// 方法2:通过ExecutorService(继承Executor,管理Thread,简化并发编程)的submit方法执行Callable
public static void main(String[] args) {
ExecutorService threadPool = Executors.newCachedThreadPool();
Future<Integer> future = threadPool.submit(new Callable<Integer>() {
public Integer call() throws Exception {
// 返回码
return new Random().nextInt(100);
}
});
try {
Thread.sleep(5000);// 模拟业务逻辑
System.out.println(future.get());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}

锁与同步

锁与同步方法是常用于保证Java操作原子性,使用锁,可以保证同一时间只有一个线程拿到锁,因此保证了同一时间只有一个线程能够执行申请锁和释放锁之间的代码。
Java Lock 实现方式:Lock详解 - Raven’s Blog

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//使用lock来实现synchronized的效果,主要区别:使用synchronized修饰的方法或代码块,在执行完之后会自动释放锁;而Lock则需要手动释放。
public class LockTest {
public static void main(String[] args) {
final Outputter1 output = new Outputter1();
new Thread() {
public void run() {
output.output("zhangsan");
};
}.start();
new Thread() {
public void run() {
output.output("lisi");
};
}.start();
}
}
class Outputter1 {
private Lock lock = new ReentrantLock();// 锁对象
public void output(String name) {
lock.lock();// 得到锁
try {
//互斥区
for(int i = 0; i < name.length(); i++) {
System.out.print(name.charAt(i));
}
} finally {
//为了保证能被释放,因此需要放在finall
lock.unlock();// 释放锁
}
}
}

相对synchronized,锁机制更具灵活性,例如,使用读写锁(ReadWriteLock - 读与写互斥、写与写互斥、但读与读不互斥,以此提高性能)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
//读写锁
public class ReadWriteLockTest {
public static void main(String[] args) {
final Data data = new Data();
for (int i = 0; i < 3; i++) {
new Thread(new Runnable() {
public void run() {
for (int j = 0; j < 5; j++) {
data.set(new Random().nextInt(30));
}
}
}).start();
}
for (int i = 0; i < 3; i++) {
new Thread(new Runnable() {
public void run() {
for (int j = 0; j < 5; j++) {
data.get();
}
}
}).start();
}
}
}
class Data {
private int data;// 共享数据
private ReadWriteLock rwl = new ReentrantReadWriteLock();
public void set(int data) {
rwl.writeLock().lock();// 取到写锁
try {
System.out.println(Thread.currentThread().getName() + "准备写入数据");
try {
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
this.data = data;
System.out.println(Thread.currentThread().getName() + "写入" + this.data);
} finally {
rwl.writeLock().unlock();// 释放写锁
}
}
public void get() {
rwl.readLock().lock();// 取到读锁
try {
System.out.println(Thread.currentThread().getName() + "准备读取数据");
try {
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "读取" + this.data);
} finally {
rwl.readLock().unlock();// 释放读锁
}
}
}

CAS(compare and swap)

Java并发包(java.util.concurrent.atomic)下,提供了原子操作类来实现原子性操作方法,从而保证原子性,而其本质是利用了CPU级别的CAS指令。(下面以AtomicInteger为例说明)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//源码中的两个有代表性的方法
//相当于原子性的++i
public final int incrementAndGet() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return next;
}
}
//相当于原子性的--i
public final int decrementAndGet() {
for (;;) {
int current = get();
int next = current - 1;
if (compareAndSet(current, next))
return next;
}
}
//两种方法都没有使用阻塞式方法来保证原子性,而是通过了CAS指令实现

  • CAS是线代CPU广泛支持的一种对内存中共享数据进行操作的特殊指令。
  • CAS操作过程简述:CPU将内存中将要被更改的数据与期望值做对比,当这两个值相等时,CPU才会将内存的数值替换为新的值,否则不操作。
  • 缺点分析:虽然CAS可以实现非阻塞式的原子性操作,但会产生ABA问题(什么是ABA问题? 例子:线程1准备用CAS将变量的值从A替换成B,但在之前,线程2将变量的值从A替换成C,又从C替换成A,然后问题来了,按照原子性理论,此时线程1不应该执行A->B才对,但线程1执行CAS时候发现变量的值仍然为A,CAS成功,这样的结果,可能会潜在未知的问题)。
  • 更详细的ABA问题见:Java CAS 与 ABA 问题

待扩展的知识点

探究线程知识过程中,发现其与许多其他知识或多或少有着联系,仍需继续努力,嗯,加油!
待扩展:Java类加载机制、JVM内存模型、Java异常分析

感谢您的阅读,希望文章对您有所帮助